from __future__ import annotations

import warnings
from dataclasses import replace
from pathlib import Path
from typing import IO, TYPE_CHECKING, Any, TextIO, overload

import ipywidgets
import traitlets as t
from ipywidgets import CallbackDispatcher

from lonboard._base import BaseAnyWidget
from lonboard._html_export import map_to_html
from lonboard._viewport import compute_view
from lonboard.basemap import CartoStyle, MaplibreBasemap
from lonboard.controls import (
    BaseControl,
    FullscreenControl,
    NavigationControl,
    ScaleControl,
)
from lonboard.experimental.view import BaseView, GlobeView, MapView
from lonboard.layer import BaseLayer
from lonboard.traits import MapHeightTrait, VariableLengthTuple, ViewStateTrait
from lonboard.view_state import BaseViewState, GlobeViewState, MapViewState

if TYPE_CHECKING:
    import sys
    from collections.abc import Callable, Sequence

    from IPython.display import HTML  # type: ignore

    from lonboard._validators.types import TraitProposal
    from lonboard.types.map import MapKwargs

    if sys.version_info >= (3, 11):
        from typing import Self
    else:
        from typing_extensions import Self

    if sys.version_info >= (3, 12):
        from typing import Unpack
    else:
        from typing_extensions import Unpack


# bundler yields lonboard/static/{index.js,styles.css}
bundler_output_dir = Path(__file__).parent / "static"


class Map(BaseAnyWidget):
    """An interactive Map.

    **Example:**

    ```py
    import geopandas as gpd
    from lonboard import Map, ScatterplotLayer, SolidPolygonLayer

    # A GeoDataFrame with Point geometries
    point_gdf = gpd.GeoDataFrame()
    point_layer = ScatterplotLayer.from_geopandas(
        point_gdf,
        get_fill_color=[255, 0, 0],
    )

    # A GeoDataFrame with Polygon geometries
    polygon_gdf = gpd.GeoDataFrame()
    polygon_layer = SolidPolygonLayer.from_geopandas(
        gdf,
        get_fill_color=[255, 0, 0],
    )

    m = Map([point_layer, polygon_layer])
    ```
    """

    def __init__(
        self,
        layers: BaseLayer | Sequence[BaseLayer],
        *,
        basemap_style: str | CartoStyle | None = None,
        **kwargs: Unpack[MapKwargs],
    ) -> None:
        """Create a new Map.

        Aside from the `layers` argument, pass keyword arguments for any other attribute
        defined in this class.

        Args:
            layers: One or more layers to render on this map.

        Keyword Args:
            basemap_style: DEPRECATED. Use `basemap` instead. A URL to a MapLibre-compatible basemap style.

                Various styles are provided in [`lonboard.basemap`](https://developmentseed.org/lonboard/latest/api/basemap/).

            kwargs: Passed on to class variables. For example, you can pass `height=600` to pass that value on to the [`height`][lonboard.Map.height] attribute.

        Returns:
            A Map object.

        """
        if basemap_style is not None:
            warnings.warn(
                "`basemap_style` is deprecated and will be removed in 0.14. Use `basemap` instead.",
                DeprecationWarning,
                stacklevel=2,
            )
            if "basemap" in kwargs:
                raise ValueError(
                    "Cannot pass both `basemap_style` and `basemap`. Use only `basemap`.",
                )
            kwargs["basemap"] = MaplibreBasemap(style=basemap_style)

        if isinstance(layers, BaseLayer):
            layers = [layers]

        def _handle_anywidget_dispatch(
            widget: ipywidgets.Widget,  # noqa: ARG001
            msg: dict,
            buffers: list[bytes],  # noqa: ARG001
        ) -> None:
            if msg.get("kind") == "on-click":
                coord = msg.get("coordinate")
                if coord is not None:
                    self._click_handlers(tuple(coord))

        super().__init__(layers=layers, **kwargs)
        self._click_handlers = CallbackDispatcher()
        self.on_msg(_handle_anywidget_dispatch)
        self.layout.height = "100%"
        self.layout.width = "100%"

    def on_click(self, callback: Callable, *, remove: bool = False) -> None:
        """Register a callback to execute when the map is clicked.

        The callback will be called with one argument, a tuple of the coordinate
        clicked (x,y)/(Longitude/Latitude).


        Args:
            callback: function callback to pass to click handler.
            remove: bool (optional)
                Set to true to remove the callback from the list of callbacks.

        !!! note
            If the map is zoomed to a very large scale and can see the earth wrapped
            around, it is possible the coordinate's x/Longitude value may be greater
            than or less than expected.  Example: If you can see Paris, France three
            times in the map, and you click on the Paris in the middle, it will show an
            X coordinate of 2, but the Paris on the left of the map will report an X
            coordinate of -358, and the Paris on the right of the map will report an
            X coordinate of 362.

        """
        self._click_handlers.register_callback(callback, remove=remove)
        self._has_click_handlers = len(self._click_handlers.callbacks) > 0

    _esm = bundler_output_dir / "index.js"
    _css = bundler_output_dir / "index.css"

    view_state: BaseViewState | None = ViewStateTrait()  # type: ignore
    """
    The view state of the map.

    - Type: A subclass of [`BaseViewState`][lonboard.view_state.BaseViewState], such as
        [`MapViewState`][lonboard.view_state.MapViewState] or
        [`GlobeViewState`][lonboard.view_state.GlobeViewState].
    - Default: Automatically inferred from the data passed to the map.

    You can initialize the map to a specific view state using this property:

    ```py
    Map(
        layers,
        view_state={"longitude": -74.0060, "latitude": 40.7128, "zoom": 7}
    )
    ```

    !!! note

        The properties of the view state are immutable. Use
        [`set_view_state`][lonboard.Map.set_view_state] to modify a map's view state
        once it's been initially rendered.

    """

    _has_click_handlers = t.Bool(default_value=False, allow_none=False).tag(sync=True)
    """
    Indicates if a click handler has been registered.
    """

    height = MapHeightTrait().tag(sync=True)
    """Height of the map in pixels, or valid CSS height property.

    For example, it can be `600` (pixels) or `"75vh"` (75% of the viewport height).

    - Type: `int` or `str`
    - Default: full height of the containing cell.
    """

    layers = VariableLengthTuple(t.Instance(BaseLayer)).tag(
        sync=True,
        **ipywidgets.widget_serialization,
    )
    """One or more [`Layer`][lonboard.BaseLayer] objects to display on this map.
    """

    controls = VariableLengthTuple(
        t.Instance(BaseControl),
        default_value=(
            FullscreenControl(),
            NavigationControl(),
            ScaleControl(),
        ),
    ).tag(
        sync=True,
        **ipywidgets.widget_serialization,
    )
    """One or more map controls to display on this map.

    See [`lonboard.controls`][] for available controls.
    """

    view: t.Instance[BaseView | None] = t.Instance(BaseView, allow_none=True).tag(
        sync=True,
        **ipywidgets.widget_serialization,
    )
    """The view to use for this map.

    Views represent the "camera(s)" (essentially viewport dimensions and projection matrices) that you look at your data with. deck.gl offers multiple view types for both geospatial and non-geospatial use cases. Read the [Views and Projections](https://deck.gl/docs/developer-guide/views) guide for the concept and examples.

    See [`lonboard.experimental.view`][] for available view types.
    """

    @t.validate("view")
    def _validate_view(
        self,
        proposal: TraitProposal[t.Instance[BaseView | None], BaseView, Self],
    ) -> BaseView:
        # if proposed view is a globe view, ensure that basemap is interleaved
        if (
            isinstance(proposal["value"], GlobeView)
            and self.basemap is not None
            and self.basemap.mode != "interleaved"
        ):
            raise t.TraitError(
                "GlobeView requires the basemap mode to be 'interleaved'. Please set `basemap.mode='interleaved'`.",
            )

        return proposal["value"]

    show_tooltip = t.Bool(default_value=False).tag(sync=True)
    """
    Whether to render a tooltip on hover on the map.

    - Type: `bool`
    - Default: `False`
    """

    show_side_panel = t.Bool(default_value=True).tag(sync=True)
    """
    Whether to render a side panel upon clicking on elements.

    - Type: `bool`
    - Default: `True`
    """

    picking_radius = t.Int(5).tag(sync=True)
    """
    Extra pixels around the pointer to include while picking (such as for a tooltip).

    This is helpful when rendered objects are difficult to target, for example
    irregularly shaped icons, small moving circles or interaction by touch.

    - Type: `int`
    - Default: `5`
    """

    basemap: t.Instance[MaplibreBasemap | None] = t.Instance(
        MaplibreBasemap,
        # If both `args` and `kw` are None, then the default value is None.
        # Set empty kw so that the default is MaplibreBasemap() with default params
        kw={},
        allow_none=True,
    ).tag(
        sync=True,
        **ipywidgets.widget_serialization,
    )
    """A basemap instance.

    See [`lonboard.basemap.MaplibreBasemap`] for more information.

    Pass `None` to disable rendering a basemap.
    """

    @t.validate("basemap")
    def _validate_basemap(
        self,
        proposal: TraitProposal[
            t.Instance[MaplibreBasemap | None],
            MaplibreBasemap,
            Self,
        ],
    ) -> MaplibreBasemap | None:
        # If proposed basemap is not interleaved, ensure current view is not globe view
        if (
            proposal["value"] is not None
            and proposal["value"].mode != "interleaved"
            and isinstance(self.view, GlobeView)
        ):
            raise t.TraitError(
                "GlobeView requires the basemap mode to be 'interleaved'. Please set `basemap.mode='interleaved'`.",
            )

        return proposal["value"]

    @property
    def basemap_style(self) -> str | None:
        """The URL of the basemap style in use."""
        warnings.warn(
            "`basemap_style` is deprecated and will be removed in 0.14. Use `basemap` instead.",
            DeprecationWarning,
            stacklevel=2,
        )

        if self.basemap is not None:
            return self.basemap.style

        return None

    @basemap_style.setter
    def basemap_style(self, value: str | CartoStyle) -> None:
        warnings.warn(
            "`basemap_style` is deprecated and will be removed in 0.14. Use `basemap` instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        self.basemap = MaplibreBasemap(style=value)

    custom_attribution = t.Union(
        [
            t.Unicode(allow_none=True),
            VariableLengthTuple(t.Unicode(allow_none=False)),
        ],
    ).tag(sync=True)
    """
    Custom attribution to display on the map.

    This attribute supports the same format as the `attribution` property in the
    Maplibre API.

    - Type: `str` or `List[str]`
    - Default: `None`

    You can provide either a single string or a list of strings for custom attributions.
    If an attribution value is set in the map style, it will be displayed in addition to
    this custom attribution.

    **Example:**

        ```py
        m = Map(
            layers,
            custom_attribution="Development Seed"
        )
        ```

    **Example:**

        ```py
        m = Map(
            layers,
            custom_attribution=["Development Seed", "OpenStreetMap"]
        )
        ```
    """

    # TODO: We'd prefer a "Strict union of bool and float" but that doesn't
    # work here because `Union[bool, float]` would coerce `1` to `True`, which we don't
    # want, and `Union[float, bool]` would coerce `True` to `1`, which we also don't
    # want.
    # In the future we could create a custom trait for this if asked for.
    use_device_pixels = t.Any(allow_none=True, default_value=None).tag(sync=True)
    """Controls the resolution of the drawing buffer used for rendering.

    Setting this to `false` or a number <= 1 will improve performance on high resolution
    displays.

    **Note**: This parameter must be passed to the `Map()` constructor. It cannot be
    changed once the map has been created.

    The available options are:

    - `true`: Device (physical) pixels resolution is used for rendering, this resolution
      is defined by `window.devicePixelRatio`. On Retina/HD systems this resolution is
      usually twice as big as CSS pixels resolution.
    - `false`: CSS pixels resolution (equal to the canvas size) is used for rendering.
    - `Number` (Experimental): Specified Number is used as a custom ratio (drawing
      buffer resolution to CSS pixel resolution) to determine drawing buffer size, a
      value less than one uses resolution smaller than CSS pixels, gives better
      performance but produces blurry images, a value greater than one uses resolution
      bigger than CSS pixels resolution (canvas size), produces sharp images but at a
      lower performance.

    - Type: `float`, `int` or `bool`
    - Default: `true`
    """

    parameters = t.Any(allow_none=True, default_value=None).tag(sync=True)
    """GPU parameters to pass to deck.gl.

    !!! Note "This is an advanced API. The vast majority of users should not need to touch this setting."

        The docstring below is copied from upstream deck.gl documentation. Any usage of
        `GL` refers to the constants defined in [`@luma.gl/constants`
        here](https://github.com/visgl/luma.gl/blob/master/modules/constants/src/webgl-constants.ts),
        which comes from the [MDN docs
        here](https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/Constants).

        In place of any `GL` constant, you can use the underlying integer it represents.
        For example, instead of the JS

        ```
        depthFunc: GL.LEQUAL
        ```

        referring to the [MDN
        docs](https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/Constants#depth_or_stencil_tests),
        you should use

        ```
        depthFunc: 0x0203
        ```

        Note that these parameters do not yet work with integer keys. If you would like
        to use integer keys, open an issue.

    Expects an object with GPU parameters. Before each frame is rendered, this object
    will be passed to luma.gl's `setParameters` function to reset the GPU context
    parameters, e.g. to disable depth testing, change blending modes etc. The default
    parameters set by `Deck` on initialization are the following:

    ```js
    {
      blend: true,
      blendFunc: [GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA, GL.ONE, GL.ONE_MINUS_SRC_ALPHA],
      polygonOffsetFill: true,
      depthTest: true,
      depthFunc: GL.LEQUAL
    }
    ```

    Refer to the luma.gl
    [setParameters](https://github.com/visgl/luma.gl/blob/8.5-release/modules/gltools/docs/api-reference/parameter-setting.md)
    API for documentation on supported parameters and values.

    ```js
    import GL from '@luma.gl/constants';
    new Deck({
      // ...
      parameters: {
        blendFunc: [GL.ONE, GL.ONE, GL.ONE, GL.ONE],
        depthTest: false
      }
    });
    ```

    Notes:

    - Any GPU `parameters` prop supplied to individual layers will still override the
      global `parameters` when that layer is rendered.
    """

    selected_bounds = t.Tuple(
        t.Float(),
        t.Float(),
        t.Float(),
        t.Float(),
        allow_none=True,
        default_value=None,
    ).tag(sync=True)
    """
    Bounds selected by the user, represented as a tuple of floats ordered as

    ```
    (minx, miny, maxx, maxy)
    ```
    """

    def add_layer(
        self,
        layers: BaseLayer | Sequence[BaseLayer] | Map,
        *,
        focus: bool = False,
        reset_zoom: bool = False,
    ) -> None:
        """Add one or more new layers to the map.

        Examples:
        ```py
        from lonboard import viz

        m = viz(some_data)
        m.add_layer(viz(more_data), focus=True)
        ```

        Args:
            layers: New layers to add to the map. This can be:

                - a layer instance
                - a list or tuple of layer instances
                - another `Map` instance, in which case its layers will be added to this
                  map. This lets you pass the result of `viz` into this method.

            focus: If True, set the view state of the map based on the _newly-added_
                layers. Defaults to False.
            reset_zoom: If True, set the view state of the map based on _all_ layers.
                Defaults to False.

        Raises:
            ValueError: _description_

        """
        if focus and reset_zoom:
            raise ValueError("focus and reset_zoom may not both be set.")

        if isinstance(layers, Map):
            new_layers = layers.layers
        elif isinstance(layers, BaseLayer):
            new_layers = (layers,)
        else:
            new_layers = tuple(layers)

        self.layers += new_layers

        if focus:
            self.view_state = compute_view(new_layers)  # type: ignore

        elif reset_zoom:
            self.view_state = compute_view(self.layers)  # type: ignore

    def set_view_state(  # noqa: PLR0913
        self,
        view_state: BaseViewState | None = None,
        *,
        longitude: float | None = None,
        latitude: float | None = None,
        zoom: float | None = None,
        pitch: float | None = None,
        bearing: float | None = None,
    ) -> None:
        """Set the view state of the map.

        Any parameters that are unset will not be changed.

        Args:
            view_state: A complete view state object to set on the map.

        Keyword Args:
            longitude: the new longitude to set on the map. Defaults to None.
            latitude: the new latitude to set on the map. Defaults to None.
            zoom: the new zoom to set on the map. Defaults to None.
            pitch: the new pitch to set on the map. Defaults to None.
            bearing: the new bearing to set on the map. Defaults to None.

        """
        if view_state is not None:
            self.view_state = view_state
            return

        current_view_state = self.view_state

        changes = {}
        if longitude is not None:
            changes["longitude"] = longitude
        if latitude is not None:
            changes["latitude"] = latitude
        if zoom is not None:
            changes["zoom"] = zoom

        # Only params allowed by globe view state
        if isinstance(current_view_state, GlobeViewState):
            self.view_state = replace(current_view_state, **changes)
            return

        # Add more params allowed by map view state
        if pitch is not None:
            changes["pitch"] = pitch
        if bearing is not None:
            changes["bearing"] = bearing

        if isinstance(current_view_state, MapViewState):
            self.view_state = replace(current_view_state, **changes)
            return

        raise TypeError(
            "Can only set MapViewState or GlobeViewState parameters individually via set_view_state.\nFor other view state types, pass a complete view_state object.",
        )

    def fly_to(  # noqa: PLR0913
        self,
        *,
        longitude: float,
        latitude: float,
        zoom: float,
        duration: int = 4000,
        pitch: float = 0,
        bearing: float = 0,
        curve: float | None = None,
        speed: float | None = None,
        screen_speed: float | None = None,
    ) -> None:
        r""" "Fly" the map to a new location.

        Args:
            longitude: The longitude of the new viewport.
            latitude: The latitude of the new viewport.
            zoom: The zoom of the new viewport.
            pitch: The pitch of the new viewport. Defaults to 0.
            bearing: The bearing of the new viewport. Defaults to 0.
            duration: The number of milliseconds for the viewport transition to take.
                Defaults to 4000.
            curve: The zooming "curve" that will occur along the flight path. Default
                `1.414`.
            speed: The average speed of the animation defined in relation to
                `curve`, it linearly affects the duration, higher speed returns smaller
                durations and vice versa. Default `1.2`.
            screen_speed: The average speed of the animation measured in screenfuls per
                second. Similar to speed it linearly affects the duration, when
                specified speed is ignored.

        """  # noqa: D210
        if not isinstance(longitude, (int, float)):
            raise TypeError(
                f"Expected longitude to be an int or float, got {type(longitude)}",
            )

        if not isinstance(latitude, (int, float)):
            raise TypeError(
                f"Expected latitude to be an int or float, got {type(latitude)}",
            )

        if not isinstance(zoom, (int, float)):
            raise TypeError(f"Expected zoom to be an int or float, got {type(zoom)}")

        data = {
            "type": "fly-to",
            "longitude": longitude,
            "latitude": latitude,
            "zoom": zoom,
            "pitch": pitch,
            "bearing": bearing,
            "transitionDuration": duration,
            "curve": curve,
            "speed": speed,
            "screenSpeed": screen_speed,
        }
        self.send(data)

    @overload
    def to_html(
        self,
        filename: None = None,
        title: str | None = None,
    ) -> str: ...

    @overload
    def to_html(
        self,
        filename: str | Path | TextIO | IO[str],
        title: str | None = None,
    ) -> None: ...

    def to_html(
        self,
        filename: str | Path | TextIO | IO[str] | None = None,
        title: str | None = None,
    ) -> str | None:
        """Save the current map as a standalone HTML file.

        Args:
            filename: where to save the generated HTML file.

        Keyword Args:
            title: A title for the exported map. This will show as the browser tab name.

        Returns:
            If `filename` is not passed, returns the HTML content as a `str`.

        """
        return map_to_html(self, filename=filename, title=title)

    def as_html(self) -> HTML:
        """Render the current map as a static HTML file in IPython.

        !!! warning

            The primary, recommended way to display a map is by
            leaving it as the last line in a cell.

            ```py
            from lonboard import Map

            m = Map(layers=[])
            m
            ```

            This method exists to support environments that are unable to display
            Jupyter Widgets. Some aspects of Lonboard are unavailable with this display
            method. In particular, the map is unable to send any information back to
            Python. So [`selected_index`][lonboard.BaseArrowLayer.selected_index] will
            never be populated, for example.

        Returns:
            IPython HTML object.

        """
        from IPython.display import HTML  # type: ignore

        return HTML(self.to_html())

    @t.default("view_state")
    def _default_initial_view_state(self) -> dict[str, Any]:
        if self.view is None or isinstance(self.view, (MapView, GlobeView)):
            return compute_view(self.layers)  # type: ignore

        return {}
